page.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. 'use client';
  2. import { use, useEffect, useState, useRef } from 'react';
  3. import * as signalR from '@microsoft/signalr';
  4. import { DonationAlertData, DonationRemoteState } from '@/types/donation';
  5. import { fetchApi } from '@/lib/utils/client';
  6. import './style.scss';
  7. type Props = {
  8. params: Promise<{ channelSID: string }>;
  9. };
  10. type AlertQueueItem = {
  11. alertID: number;
  12. donationID: number;
  13. status: string;
  14. sponsorMemberID: number;
  15. sendName: string;
  16. amount: number;
  17. message: string|null;
  18. channelName?: string;
  19. createdAt: string;
  20. };
  21. const STATUS_LABEL: Record<string, string> = {
  22. playing: '재생 중',
  23. queued: '대기',
  24. failed: '실패',
  25. delivered: '완료',
  26. skipped: '건너뜀',
  27. ignored: '무시'
  28. };
  29. export default function RemotePage({ params }: Props) {
  30. const { channelSID } = use(params);
  31. const apiBase = '/api/donation/remote';
  32. const hubUrl = process.env.NEXT_PUBLIC_API_URL + '/hubs/donation';
  33. const [connected, setConnected] = useState(false);
  34. const [state, setState] = useState<DonationRemoteState>({ isPaused: false, isAccepting: true, isAudioOnly: false, isVideoOnly: false });
  35. const [queue, setQueue] = useState<AlertQueueItem[]>([]);
  36. const [openMenuID, setOpenMenuID] = useState<number|null>(null);
  37. const connectionRef = useRef<signalR.HubConnection|null>(null);
  38. // 초기 데이터 로드 + SignalR 연결
  39. useEffect(() => {
  40. loadState();
  41. connectHub();
  42. return () => { connectionRef.current?.stop(); };
  43. }, []);
  44. const loadState = async () => {
  45. try {
  46. const res = await fetchApi<DonationRemoteState & { queue: AlertQueueItem[] }>(`${apiBase}/state/${channelSID}`, { silent: true });
  47. if (res.data) {
  48. setState({ isPaused: res.data.isPaused, isAccepting: res.data.isAccepting, isAudioOnly: res.data.isAudioOnly, isVideoOnly: res.data.isVideoOnly });
  49. setQueue(res.data.queue || []);
  50. }
  51. } catch {}
  52. };
  53. const connectHub = () => {
  54. const conn = new signalR.HubConnectionBuilder().withUrl(hubUrl).withAutomaticReconnect().build();
  55. conn.on('ReceiveAlert', (data: DonationAlertData) => {
  56. setQueue(prev => [...prev, { alertID: data.alertID, donationID: data.donationID, status: 'queued', sponsorMemberID: data.sponsorMemberID, sendName: data.sendName, amount: data.amount, message: data.message, createdAt: data.createdAt }]);
  57. });
  58. conn.on('ReceiveState', (s: DonationRemoteState) => setState(s));
  59. conn.on('ReceiveSkip', () => {
  60. setQueue(prev => prev.map(q => q.status === 'playing' ? { ...q, status: 'skipped' } : q));
  61. });
  62. conn.start().then(() => {
  63. conn.invoke('JoinChannel', channelSID);
  64. setConnected(true);
  65. }).catch(() => {});
  66. conn.onclose(() => setConnected(false));
  67. conn.onreconnected(() => { conn.invoke('JoinChannel', channelSID); setConnected(true); });
  68. connectionRef.current = conn;
  69. };
  70. // 리모콘 액션
  71. const togglePause = async () => {
  72. const next = { ...state, isPaused: !state.isPaused };
  73. setState(next);
  74. await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, channelID: 0, memberID: 0 }, silent: true });
  75. };
  76. const toggleAccepting = async () => {
  77. const next = { ...state, isAccepting: !state.isAccepting };
  78. setState(next);
  79. await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, channelID: 0, memberID: 0 }, silent: true });
  80. };
  81. const toggleAudioOnly = async () => {
  82. const next = { ...state, isAudioOnly: !state.isAudioOnly, isVideoOnly: false };
  83. setState(next);
  84. await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, channelID: 0, memberID: 0 }, silent: true });
  85. };
  86. const toggleVideoOnly = async () => {
  87. const next = { ...state, isVideoOnly: !state.isVideoOnly, isAudioOnly: false };
  88. setState(next);
  89. await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, channelID: 0, memberID: 0 }, silent: true });
  90. };
  91. const skipCurrent = async () => {
  92. await fetchApi(`${apiBase}/skip/${channelSID}`, { method: 'POST', silent: true });
  93. };
  94. const ignoreAlert = async (alertID: number) => {
  95. await fetchApi(`${apiBase}/ignore/${alertID}`, { method: 'POST', silent: true });
  96. setQueue(prev => prev.map(q => q.alertID === alertID ? { ...q, status: 'ignored' } : q));
  97. setOpenMenuID(null);
  98. };
  99. const resendAlert = async (alertID: number) => {
  100. await fetchApi(`${apiBase}/resend/${alertID}`, { method: 'POST', silent: true });
  101. setQueue(prev => prev.map(q => q.alertID === alertID ? { ...q, status: 'queued' } : q));
  102. setOpenMenuID(null);
  103. };
  104. const formatTime = (dateStr: string) => {
  105. const d = new Date(dateStr);
  106. return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
  107. };
  108. return (
  109. <div className="remote-container">
  110. <div className="remote-header">
  111. <h1>리모콘</h1>
  112. <span className={`connection-badge ${connected ? 'connected' : 'disconnected'}`}>
  113. {connected ? '연결됨' : '연결 끊김'}
  114. </span>
  115. </div>
  116. {/* 컨트롤 패널 */}
  117. <div className="control-panel">
  118. <button type="button" className={`control-btn ${state.isPaused ? 'active' : ''}`} onClick={togglePause}>
  119. {state.isPaused ? '▶ 재개' : '⏸ 일시정지'}
  120. </button>
  121. <button type="button" className={`control-btn ${!state.isAccepting ? 'danger' : ''}`} onClick={toggleAccepting}>
  122. {state.isAccepting ? '🔔 후원 받는 중' : '🔕 후원 안 받음'}
  123. </button>
  124. <button type="button" className={`control-btn ${state.isAudioOnly ? 'active' : ''}`} onClick={toggleAudioOnly}>
  125. 🔊 음성만
  126. </button>
  127. <button type="button" className={`control-btn ${state.isVideoOnly ? 'active' : ''}`} onClick={toggleVideoOnly}>
  128. 🖼 영상만
  129. </button>
  130. <button type="button" className="control-btn full-width" onClick={skipCurrent}>
  131. ⏭ 건너뛰기
  132. </button>
  133. </div>
  134. {/* 후원 목록 */}
  135. <div className="alert-list-header">
  136. <h2>후원 목록</h2>
  137. <span className="alert-count">{queue.length}건</span>
  138. </div>
  139. <div className="alert-list">
  140. {queue.length === 0 && <div className="empty-list">후원 알림이 없습니다</div>}
  141. {queue.map(item => (
  142. <div key={item.alertID} className={`alert-item ${item.status}`}>
  143. {/* 방향 아이콘 */}
  144. <div className="alert-direction">
  145. <span>{item.sendName}</span>
  146. <span className="alert-arrow">→</span>
  147. </div>
  148. {/* 본문 */}
  149. <div className="alert-body">
  150. <div className="alert-body-top">
  151. <span className="alert-sender-name">{item.sendName}</span>
  152. <span className="alert-amount">{item.amount.toLocaleString()}원</span>
  153. </div>
  154. {item.message && <div className="alert-msg">{item.message}</div>}
  155. <div className="alert-time">{formatTime(item.createdAt)}</div>
  156. </div>
  157. {/* 상태 뱃지 */}
  158. <span className={`alert-status-badge status-${item.status}`}>
  159. {STATUS_LABEL[item.status] || item.status}
  160. </span>
  161. {/* 햄버거 메뉴 (재생 중이 아닐 때만) */}
  162. {item.status !== 'playing' && (
  163. <div className="relative">
  164. <button type="button" className="alert-menu-btn" onClick={() => setOpenMenuID(openMenuID === item.alertID ? null : item.alertID)}>
  165. </button>
  166. {openMenuID === item.alertID && (
  167. <div className="alert-menu-dropdown">
  168. {(item.status === 'failed' || item.status === 'skipped') && (
  169. <div className="alert-menu-item" onClick={() => resendAlert(item.alertID)}>재전송</div>
  170. )}
  171. {item.status === 'queued' && (
  172. <div className="alert-menu-item danger" onClick={() => ignoreAlert(item.alertID)}>무시</div>
  173. )}
  174. </div>
  175. )}
  176. </div>
  177. )}
  178. </div>
  179. ))}
  180. </div>
  181. </div>
  182. );
  183. }